Skip to content

tmpdir: prevent symlink attacks and TOCTOU races (CVE-2025-71176)#14279

Open
laurac8r wants to merge 43 commits intopytest-dev:mainfrom
laurac8r:hotfix/cve
Open

tmpdir: prevent symlink attacks and TOCTOU races (CVE-2025-71176)#14279
laurac8r wants to merge 43 commits intopytest-dev:mainfrom
laurac8r:hotfix/cve

Conversation

@laurac8r
Copy link

@laurac8r laurac8r commented Mar 9, 2026

Summary

Replace the predictable pytest-of-<user> rootdir with a randomly-named rootdir created via tempfile.mkdtemp to prevent the entire class of predictable-name attacks (symlink races, DoS via pre-created files/dirs). As defense-in-depth, open the rootdir with os.open using O_NOFOLLOW and O_DIRECTORY flags and perform ownership/permission checks via fd-based fstat/fchmod to eliminate TOCTOU races.

Fixes CVE-2025-71176.

closes #13669

Changes

  • Randomly-named rootdir: Use tempfile.mkdtemp(prefix=f"pytest-of-{user}-", dir=temproot) instead of a fixed pytest-of-{user} directory, making pre-creation attacks infeasible.
  • _safe_open_dir context manager: Opens the rootdir with O_NOFOLLOW | O_DIRECTORY (where available), yields the fd, and guarantees cleanup. Symlinks are rejected at the os.open level.
  • fd-based ownership & permission checks: fstat and fchmod operate on the open file descriptor, eliminating the TOCTOU window between stat/chmod and the actual directory.
  • _cleanup_old_rootdirs: Garbage-collects stale randomly-named rootdirs from previous sessions, respecting the retention count. The current session's rootdir is never removed.
  • Comprehensive tests: Symlink rejection, wrong-owner rejection, missing O_NOFOLLOW/O_DIRECTORY fallback, predictable-path DoS immunity, _cleanup_old_rootdirs behavior, _safe_open_dir fd lifecycle, config validation, and session-finish edge cases.

  • Include documentation when adding new features.
  • Include new tests or update existing tests when applicable.
  • Allow maintainers to push and squash when merging my commits. Please uncheck this if you prefer to squash the commits yourself.

If this change fixes an issue, please:

  • Add text like closes #XYZW to the PR description and/or commits (where XYZW is the issue number). See the github docs for more information.

Important

Unsupervised agentic contributions are not accepted. See our AI/LLM-Assisted Contributions Policy.

  • If AI agents were used, they are credited in Co-authored-by commit trailers.

Unless your change is trivial or a small documentation fix (e.g., a typo or reword of a small section) please:

  • Create a new changelog file in the changelog directory, with a name like <ISSUE NUMBER>.<TYPE>.rst. See changelog/README.rst for details.

    Write sentences in the past or present tense, examples:

    • Improved verbose diff output with sequences.
    • Terminal summary statistics now use multiple colors.

    Also make sure to end the sentence with a ..

  • Add yourself to AUTHORS in alphabetical order.

Laura Kaminskiy added 3 commits March 9, 2026 00:49
Open the base temporary directory using `os.open` with `O_NOFOLLOW` and `O_DIRECTORY` flags to prevent symlink attacks.
Use the resulting file descriptor for `fstat` and `fchmod` operations to eliminate Time-of-Check Time-of-Use (TOCTOU) races.

Co-authored-by: Windsurf, Gemini
Co-authored-by: Windsurf, Gemini
@psf-chronographer psf-chronographer bot added the bot:chronographer:provided (automation) changelog entry is part of PR label Mar 9, 2026
@orlitzky
Copy link

orlitzky commented Mar 9, 2026

This certainly looks like an improvement, but it's still possible for any user to DoS the machine until a reboot with for x in $(cat /etc/passwd | cut -f1 -d:); do touch /tmp/pytest-of-$x; done;.

laurac8r and others added 6 commits March 10, 2026 22:55
Co-authored-by: 🇺🇦 Sviatoslav Sydorenko (Святослав Сидоренко) <wk.cvs.github@sydorenko.org.ua>
Co-authored-by: 🇺🇦 Sviatoslav Sydorenko (Святослав Сидоренко) <wk.cvs.github@sydorenko.org.ua>
# Conflicts:
#	changelog/13669.bugfix.rst
laura and others added 15 commits March 15, 2026 14:17
…try_ensure_directory

Cover the race-condition branch where `unlink` succeeds but the
subsequent `mkdir` still raises an `OSError` (e.g. a concurrent
process recreated the path between the two calls).  Verifies that
`_try_ensure_directory` returns `None` rather than propagating the
exception.

Co-authored-by: Windsurf
@laurac8r
Copy link
Author

laurac8r commented Mar 15, 2026

This certainly looks like an improvement, but it's still possible for any user to DoS the machine until a reboot with for x in $(cat /etc/passwd | cut -f1 -d:); do touch /tmp/pytest-of-$x; done;.

Great callout Michael! This attack route is now blocked by a safety check before creating the directory: If a non-directory file is blocking the path (e.g. placed by another user), we attempt to remove that file and then retry.

@orlitzky
Copy link

Great callout Michael! This attack route is now blocked by a safety check before creating the directory: If a non-directory file is blocking the path (e.g. placed by another user), we attempt to remove that file and then retry.

This cannot work unfortunately: the file is owned by the attacker, so I cannot delete his files from /tmp unless I am root. I indirectly mentioned this in #13669 (comment) where I pointed out that it's silly to tell me to fix the situation.

I don't want to sound like an asshole always coming back with new caveats to every approach, but the truth is that this is very old class of vulnerability and all of the ways to exploit it are well-known. (I was not auditing the code, I just saw the fixed path one day and knew immediately that it would be exploitable.) The solution is to use a carefully-created randomly-named path; so if someone proposes a solution that does not involve mkstemp() or something like it under the hood, then it's very easy to look up what the problem was with that approach.

@laurac8r
Copy link
Author

laurac8r commented Mar 15, 2026

Great callout Michael! This attack route is now blocked by a safety check before creating the directory: If a non-directory file is blocking the path (e.g. placed by another user), we attempt to remove that file and then retry.

This cannot work unfortunately: the file is owned by the attacker, so I cannot delete his files from /tmp unless I am root. I indirectly mentioned this in #13669 (comment) where I pointed out that it's silly to tell me to fix the situation.

I don't want to sound like an asshole always coming back with new caveats to every approach, but the truth is that this is very old class of vulnerability and all of the ways to exploit it are well-known. (I was not auditing the code, I just saw the fixed path one day and knew immediately that it would be exploitable.) The solution is to use a carefully-created randomly-named path; so if someone proposes a solution that does not involve mkstemp() or something like it under the hood, then it's very easy to look up what the problem was with that approach.

Fixed!

Previously, TempPathFactory created a fixed, predictable directory (/tmp/pytest-of-<user>) as the rootdir, which enabled a class of attacks: an adversary could pre-create files or symlinks at that path to DoS pytest or exploit TOCTOU races (CVE-2025-71176).

I've replaced _try_ensure_directory() with tempfile.mkdtemp(), which atomically creates a randomly-suffixed directory (pytest-of-<user>-XXXX) that cannot be predicted or pre-empted by an attacker. os.chmod(0o700) is applied unconditionally after mkdtemp, and the existing fd-based fchmod inside _safe_open_dir provides defense-in-depth against TOCTOU.

Because mkdtemp now produces a fresh directory every session, add _cleanup_old_rootdirs() to prune stale rootdirs from previous sessions, retaining only the keep most recent (by mtime).

laura and others added 4 commits March 15, 2026 16:00
- Remove blank line before tmppath_result_key assignment
- Collapse multi-line Path(tempfile.mkdtemp(...)) calls to single lines
- Remove unused `getpass` import and `original_chmod` variable in tests
- Add explicit `str` type annotation to `path` variable in symlink test
- Add blank line before test_getbasetemp_uses_mkdtemp_rootdir function
- Collapse multi-line sorted() call in TestCleanupOldRootdirs to single line

Co-authored-by: Windsurf
…missions instead of no-op

Replace the `_noop_chmod` stub (which simply skipped our chmod call) with
`_widen_chmod`, which actively sets permissions to 0o755.  This makes the
test more realistic: `fchmod` must now *correct* an adversarially-widened
directory, not merely compensate for a missing tightening step.

Co-authored-by: Windsurf
@orlitzky
Copy link

Fixed!

The new approach looks good to me FWIW.

mkdtemp() guarantees that the directory will be 0700 and owned by the current user, so the pre-existing chmod/stat safeguards conveniently become redundant if this approach is adopted :)

@laurac8r
Copy link
Author

the pre-existing chmod/stat safeguards conveniently become redundant if this approach is adopted :)

TY Michael, and let's leave it to another enthusiastic coder ;) and close the book on this CVE

@laurac8r laurac8r requested a review from webknjaz March 17, 2026 00:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:chronographer:provided (automation) changelog entry is part of PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Vulnerable tmpdir handling (CVE-2025-71176)

3 participants